Skip to content

perf(rknn): price a ladder engine by what the driver reports - #970

Merged
0xShug0 merged 2 commits into
0xShug0:mainfrom
wOvAN:rknn-aperture-measured-cost
Oct 18, 2026
Merged

0xShug0 merged 2 commits into
0xShug0:mainfrom
wOvAN:rknn-aperture-measured-cost

Conversation

@wOvAN

@wOvAN wOvAN commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

What changes

The CosyVoice3 flow-estimator ladder decides which compiled engines it keeps resident with a linear proxy: 5 MiB per compiled frame, against a 2800 MiB ceiling. The proxy exists because the cost of a context is not its image size — all six estimator engines are 661-714 MB on disk and occupy 0.27-3.7 GiB in the NPU I/O aperture — but the proxy is a guess that inherits its shape from the long rungs, and it prices the ladder wrong in both directions:

engine proxy (5 MiB/frame) driver report
S=110 550 MiB 786 MiB
S=200 1000 MiB 1129 MiB
S=250 1250 MiB 1426 MiB
S=400 2000 MiB 2615 MiB
S=500 2500 MiB 3752 MiB
S=900 4500 MiB 1039 MiB

Two consequences. The cost of a rung is not a function of its compiled length (S=900 is cheaper than S=500), and the proxy's over-estimate on the short rungs makes the ladder refuse sets the board admits: 200+400 costs 3000 MiB by the proxy, so the ladder evicts the short rung, while the board initializes that pair in one process at 3744 MiB. A ladder that could hold two rungs re-inits one of them on every length change — ~2.8 s of rknn_init per request.

How the number is read

engine::rknn::engine_memory_mib(path, label): rknn_init with RKNN_FLAG_COLLECT_MODEL_INFO_ONLY parses the image without building the network (~0.5 s per engine, no NPU memory allocated), and rknn_query(RKNN_QUERY_MEM_SIZE) then reports total_weight_size + total_internal_size — the bytes the real init goes on to allocate, less a per-context slack of 30-50 MiB measured on this board. It returns 0 when the driver cannot say, so each sidecar keeps its own proxy as the fallback. Called once per engine at ladder registration, not per request.

ApertureBudget now counts MiB, its only unit, and keeps no ceiling of its own; the ceiling stays model-local with the matrix it was derived from (kAperturePinBudgetMiB = 3900 in rknn_flow.cpp).

Runtime validation (RK3588, Orange Pi 5 Plus, 8 GiB, librknnrt 2.3.2)

Board build: cmake --build /root/audio.cpp/build -j 8 --target audiocpp_server with -DENGINE_ENABLE_RKNN=ON -DENGINE_RKNN_INCLUDE_DIR=/usr/local/include -DENGINE_RKNN_LIBRARY=/usr/lib/librknnrt.so, model set piper_tts,kokoro_tts,qwen3_tts,cosyvoice3,f5_tts,voxcpm2.

Two binaries from one tree — main (bin-proxy) and this branch (bin-measured) — one server config listing all six rungs
(/root/srvconc/server-rknn-full.json, cosyvoice3.exec_plan → flow_estimator_seq{110,200,250,400,500,900}.rknn
from /root/audio.cpp/models/CosyVoice3-RKNN/), GGUF
/root/audio.cpp/models/CosyVoice3-GGUF/cosyvoice3-q8_0.gguf, runner
/root/pinladder/ab9-run.sh: cold server, six POST /v1/audio/speech
(--backend cpu --threads 4, zero-shot voice from /root/srvconc/ref.wav, seed=1234) alternating
between the S=200 and S=400 rungs (n=4,50,10,50,4,50 chars → 118/308/158/308/118/308 mel frames).

main this branch
rknn_init calls per run 6 (200,400 alternating) 2 (both resident, resident_mib 3744)
wall, six requests 154.86 s 141.74 s (−8.5%)
wall of a request needing no new load 13.69 / 12.63 s 10.91 / 9.76 s
peak RSS 5.09 GiB 6.20 GiB
md5 of the three served clips identical between the two runs

The saving is exactly one rknn_init per request that needs no new engine; the price is the second engine's residency, visible in RSS. Registration logs the table (cosyvoice3.flow.dit.rknn.aperture_mib = 786/1129/1426/2615/3752/1039 on this board) and resident_mib the live set.

Ceiling evidence, same board, sets initialized in one process (/root/memq/pairs.c): 110+200+250 (3341 MiB), 110×4 (3144), 200×3 (3387), 250+250+110 (3638), 110+400 (3401) and 200+400 (3744) all rknn_init cleanly, while 250+400 (4041), 110+500 (4538) and every set holding an S≥900 engine return -4. 3900 MiB is that measured boundary, not a board property — the accounting is shared, the numbers are this engine's.

Trace showing the policy difference:

main:       loaded_frames 200 → resident_frames 200 → loaded_frames 400 → resident_frames 400 →  (6 loads)
this branch: loaded_frames 200 → resident_mib 1129 → loaded_frames 400 → resident_mib 3744 →     (2 loads)

Host

rknn_engine_test passes (ENGINE_BUILD_EXTENDED_TESTS=ON; the suite is registered under that gate): the served ladder trace re-run with the measured costs gives 2 loads and a 2555 MiB resident set, 200+400 fits, 250+400 (4041) and 110+500 (4538) do not, and S=900's own 1039 MiB fits the aperture while the engine still fails at its own IOVA ceiling. python3 tools/check_loader_catalog_sync.py --self-test && python3 tools/check_loader_catalog_sync.py → ok.

Known limits

  • The per-engine costs and the 3900 MiB ceiling are measurements of this estimator on this board. A family that wants the shared accounting has to measure its own; the type takes MiB in and holds no ceiling, so nothing here imports a CosyVoice3 number into another sidecar.
  • engine_memory_mib costs ~0.5 s per engine at startup (3 s for a six-rung ladder), once, in exchange for the loads it removes per request.
  • The S≥900 engines are still refused by their own per-engine ceiling (~4.7 GB requested) and fall back to the CPU graph; the measured model does not change that.

The flow-estimator ladder pins its resident rungs with a linear proxy, 5 MiB per
compiled frame, because the cost of a context is not its image size (all six
estimator engines are 661-714 MB and occupy 0.27-3.7 GiB). The proxy is a guess
with a shape it inherits from the long rungs: it over-pins the short ones by up to
11x (S=110 really costs 786 MiB, the proxy charges 550) and it refuses sets the
board admits, so a ladder that could hold two rungs at once re-inits one per
request.

Read the number from the driver instead: `rknn_init` with
`RKNN_FLAG_COLLECT_MODEL_INFO_ONLY` parses the image without building the network
(~0.5 s per engine, no NPU memory allocated) and `RKNN_QUERY_MEM_SIZE` then reports
`total_weight_size + total_internal_size`, the bytes the real init goes on to
allocate, less a per-context slack of 30-50 MiB. `engine::rknn::engine_memory_mib`
does that once per engine at registration and returns 0 when the driver cannot say,
which keeps the proxy as the fallback. `ApertureBudget` therefore counts MiB, its
only unit, and the ceiling this board fits is 3900 MiB: 110+200+250 (3341), 110x4
(3144), 200x3 (3387), 250+250+110 (3638), 110+400 (3401) and 200+400 (3744)
initialize together in one process, while 250+400 (4041), 110+500 (4538) and every
set holding an S>=900 engine fail at `rknn_init` with -4.

Measured on RK3588 with the six-rung ladder and six requests alternating between
the 200 and 400 rungs: the proxy pins one rung at a time and re-inits on every
length change (6 `rknn_init` calls, 154.9 s of request wall time, peak RSS 5.09
GiB), the measured cost keeps 200+400 co-resident at 3744 MiB (2 loads, 141.7 s,
-8.5%, peak RSS 6.20 GiB - the second engine's residency, paid for the loads).
The served audio is bit-identical between the runs (same engines, same padded
runs).
@0xShug0

0xShug0 commented Oct 18, 2026

Copy link
Copy Markdown
Owner

@wOvAN The PR undercounts engine memory because it adds two 32-bit byte counts before widening the result. Example: 1 GiB + 3.5 GiB = 4.5 GiB, but the addition may overflow and reports 512 MiB.

`rknn_mem_size` carries `total_weight_size` and `total_internal_size` as
`uint32_t` (rknn_api.h 2.3.2), so adding them and then casting widened the result
of a sum that had already wrapped: a 1 GiB weight set beside a 3.5 GiB workspace
reports 512 MiB. Widen the first operand, then add and round.

The table this branch ships is unaffected: the largest sum on the served ladder is
S=500 at 668604160 + 3265074496 = 3933678656 bytes, under the 32-bit ceiling, so
786/1129/1426/2615/3752/1039 MiB stand. The wrap bites above 4 GiB, which is where
the S>=1100 engines sit.
@wOvAN

wOvAN commented Oct 18, 2026

Copy link
Copy Markdown
Contributor Author

Confirmed, thanks — rknn_api.h 2.3.2 declares both fields as uint32_t (total_weight_size, total_internal_size), so the sum wrapped in 32 bits before the cast widened it. Reproduced host-side with your operands:

1073741824 + 3758096384 = 536870912 (as uint32_t)  ->  old: 512 MiB,  fixed: 4608 MiB

Fixed in bc86e586: widen the first operand, then add, then round up.

The shipped table is unaffected, so the board evidence stands — the largest sum on the served ladder is S=500 at 668604160 + 3265074496 = 3933678656 bytes, below the 32-bit ceiling, and the fixed binary re-ran the same A/B with the same numbers (aperture_mib 786/1129/1426/2615/3752/1039, loads 2, resident_mib 3744, peak RSS 6.22 GiB, identical clip digests). The wrap bites above 4 GiB, which is where the S>=1100 engines sit — the ones that never initialize on this board, so it was invisible to the measurements here.

@0xShug0
0xShug0 merged commit 9fd6a38 into 0xShug0:main Oct 18, 2026
6 checks passed
@0xShug0

0xShug0 commented Oct 18, 2026

Copy link
Copy Markdown
Owner

@wOvAN Thanks for the quick turnaround! PR merged!

cunba-ai pushed a commit to cunba-ai/audio.cpp that referenced this pull request Oct 28, 2026
…ke-word/Whistle NEON + Drama3 deepening + per-model server device selection

Upstream commits a8ba5a3..5074956 (16):
- shared object libraries declared in model registrations (0xShug0#966)
- OpenWakeWord Alexa GGUF package (0xShug0#968)
- Whistle host decoder restored + opt-in NEON acceleration (0xShug0#967)
- opt-in NEON network execution for microWakeWord (0xShug0#969)
- fish_drama3: per-layer expert counts, speech-only head (0xShug0#959)
- server: exec_plan engines counted in free-memory guard (0xShug0#964)
- server: config model settings kept on WebUI loads, demo voices with --ui (0xShug0#971)
- fix(rknn): exec_plan engine lookup next to the model (0xShug0#963)
- webui: configured models treated same with/without UI management (0xShug0#972)
- perf(rknn): ladder engine priced by driver report (0xShug0#970)
- feat(server): per-model backend device selection (0xShug0#973)
- perf(fish_dac): chunked reference encoding cuts VRAM peak (0xShug0#960)
- webui: Vietnamese interface translations (0xShug0#839)
- Drama3 base/small release cleanup (0xShug0#976)
- text chunking: closing quotes kept with their sentence, tag-aware (0xShug0#977)
- fish_drama3: default top_k 4097 no cap, matching Fish's Space (0xShug0#978)

# Conflicts:
#	src/community_models/whistle_asr/session.cpp
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants